Micron Document
baubs git

Node / mirrors / respira / commits / e1aadc9

Commit e1aadc9e1fd726d5c7dcd48f51284e21c9fd4c6c


Parents : 2372278
Author : Jan-Henrik Bruhn <jan-henrik.bruhn@offis.de>
Date : 2025-12-27T12:19:12+01:00

feature: Create comprehensive custom hooks library (WIP)

- Extract 5 new custom hooks:
* useAutoScroll - Auto-scroll element into view
* useClickOutside - Detect outside clicks with exclusions
* useMachinePolling - Dynamic machine status polling
* useErrorPopoverState - Error popover state management
* useBluetoothDeviceListener - Bluetooth device discovery

- Reorganize all hooks into categorized folders:
* utility/ - Generic reusable patterns
* domain/ - Business logic for embroidery/patterns
* ui/ - Library integration (Konva)
* platform/ - Electron/Pyodide specific

- Create barrel exports for clean imports (@/hooks)

- Update components to use new hooks:
* AppHeader uses useErrorPopoverState
* ProgressMonitor uses useAutoScroll
* FileUpload, PatternCanvas use barrel exports

Part 1: Hooks extraction and reorganization
Still TODO: Update remaining components, add tests, add documentation

Related to #40

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Changes
Diff

diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index c100d06..51af7f1 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -1,8 +1,7 @@
-import { useState, useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore";
-import { usePrevious } from "../hooks/usePrevious";
+import { useErrorPopoverState } from "@/hooks";
import { WorkflowStepper } from "./WorkflowStepper";
import { ErrorPopoverContent } from "./ErrorPopover";
import {
@@ -61,17 +60,16 @@ export function AppHeader() {
})),
);
- // State management for error popover auto-open/close
- const [errorPopoverOpen, setErrorPopoverOpen] = useState(false);
- const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
- null,
- );
- const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false);
-
- // Track previous values for comparison
- const prevMachineError = usePrevious(machineError);
- const prevErrorMessage = usePrevious(machineErrorMessage);
- const prevPyodideError = usePrevious(pyodideError);
+ // Error popover state management
+ const {
+ isOpen: errorPopoverOpen,
+ handleOpenChange: handlePopoverOpenChange,
+ } = useErrorPopoverState({
+ machineError,
+ machineErrorMessage,
+ pyodideError,
+ hasError,
+ });
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
@@ -90,67 +88,6 @@ export function AppHeader() {
? getStatusIndicatorState(machineStatus)
: "idle";
- // Auto-open/close error popover based on error state changes
- /* eslint-disable react-hooks/set-state-in-effect */
- useEffect(() => {
- // Check if there's any error now
- const hasAnyError =
- machineErrorMessage || pyodideError || hasError(machineError);
- // Check if there was any error before
- const hadAnyError =
- prevErrorMessage || prevPyodideError || hasError(prevMachineError);
-
- // Auto-open popover when new error appears (but not if user manually dismissed)
- const isNewMachineError =
- hasError(machineError) &&
- machineError !== prevMachineError &&
- machineError !== dismissedErrorCode;
- const isNewErrorMessage =
- machineErrorMessage && machineErrorMessage !== prevErrorMessage;
- const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError;
-
- if (
- !wasManuallyDismissed &&
- (isNewMachineError || isNewErrorMessage || isNewPyodideError)
- ) {
- setErrorPopoverOpen(true);
- }
-
- // Auto-close popover when all errors are cleared
- if (!hasAnyError && hadAnyError) {
- setErrorPopoverOpen(false);
- setDismissedErrorCode(null); // Reset dismissed tracking
- setWasManuallyDismissed(false); // Reset manual dismissal flag
- }
- }, [
- machineError,
- machineErrorMessage,
- pyodideError,
- dismissedErrorCode,
- wasManuallyDismissed,
- prevMachineError,
- prevErrorMessage,
- prevPyodideError,
- ]);
- /* eslint-enable react-hooks/set-state-in-effect */
-
- // Handle manual popover dismiss
- const handlePopoverOpenChange = (open: boolean) => {
- setErrorPopoverOpen(open);
-
- // If user manually closes it while any error is present, remember this to prevent reopening
- if (
- !open &&
- (hasError(machineError) || machineErrorMessage || pyodideError)
- ) {
- setWasManuallyDismissed(true);
- // Also track the specific machine error code if present
- if (hasError(machineError)) {
- setDismissedErrorCode(machineError);
- }
- }
- };
-
return (
<TooltipProvider>
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">

diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx
index 394df09..f89990d 100644
--- a/src/components/FileUpload.tsx
+++ b/src/components/FileUpload.tsx
@@ -10,9 +10,11 @@ import {
canUploadPattern,
getMachineStateCategory,
} from "../utils/machineStateHelpers";
-import { useFileUpload } from "../hooks/useFileUpload";
-import { usePatternRotationUpload } from "../hooks/usePatternRotationUpload";
-import { usePatternValidation } from "../hooks/usePatternValidation";
+import {
+ useFileUpload,
+ usePatternRotationUpload,
+ usePatternValidation,
+} from "@/hooks";
import { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo";
import {

diff --git a/src/components/PatternCanvas/PatternCanvas.tsx b/src/components/PatternCanvas/PatternCanvas.tsx
index e7cda97..f791876 100644
--- a/src/components/PatternCanvas/PatternCanvas.tsx
+++ b/src/components/PatternCanvas/PatternCanvas.tsx
@@ -21,8 +21,7 @@ import { ThreadLegend } from "./ThreadLegend";
import { PatternPositionIndicator } from "./PatternPositionIndicator";
import { ZoomControls } from "./ZoomControls";
import { PatternLayer } from "./PatternLayer";
-import { useCanvasViewport } from "../../hooks/useCanvasViewport";
-import { usePatternTransform } from "../../hooks/usePatternTransform";
+import { useCanvasViewport, usePatternTransform } from "@/hooks";
export function PatternCanvas() {
// Machine store

diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx
index 26fea1f..bebb05a 100644
--- a/src/components/ProgressMonitor.tsx
+++ b/src/components/ProgressMonitor.tsx
@@ -1,4 +1,5 @@
-import { useRef, useEffect, useMemo } from "react";
+import { useMemo } from "react";
+import { useAutoScroll } from "@/hooks";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { usePatternStore } from "../stores/usePatternStore";
@@ -54,7 +55,6 @@ export function ProgressMonitor() {
const pesData = usePatternStore((state) => state.pesData);
const uploadedPesData = usePatternStore((state) => state.uploadedPesData);
const displayPattern = uploadedPesData || pesData;
- const currentBlockRef = useRef<HTMLDivElement>(null);
// State indicators
const isMaskTraceComplete =
@@ -127,14 +127,7 @@ export function ProgressMonitor() {
}, [colorBlocks, currentStitch]);
// Auto-scroll to current block
- useEffect(() => {
- if (currentBlockRef.current) {
- currentBlockRef.current.scrollIntoView({
- behavior: "smooth",
- block: "nearest",
- });
- }
- }, [currentBlockIndex]);
+ const currentBlockRef = useAutoScroll(currentBlockIndex);
return (
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">

diff --git a/src/hooks/domain/index.ts b/src/hooks/domain/index.ts
new file mode 100644
index 0000000..200ebd1
--- /dev/null
+++ b/src/hooks/domain/index.ts
@@ -0,0 +1,12 @@
+export { usePatternValidation } from "./usePatternValidation";
+export { usePatternRotationUpload } from "./usePatternRotationUpload";
+export { useMachinePolling } from "./useMachinePolling";
+export { useErrorPopoverState } from "./useErrorPopoverState";
+export type {
+ UseMachinePollingOptions,
+ UseMachinePollingReturn,
+} from "./useMachinePolling";
+export type {
+ UseErrorPopoverStateOptions,
+ UseErrorPopoverStateReturn,
+} from "./useErrorPopoverState";

diff --git a/src/hooks/domain/useErrorPopoverState.ts b/src/hooks/domain/useErrorPopoverState.ts
new file mode 100644
index 0000000..7e836b7
--- /dev/null
+++ b/src/hooks/domain/useErrorPopoverState.ts
@@ -0,0 +1,137 @@
+/**
+ * useErrorPopoverState Hook
+ *
+ * Manages error popover state with sophisticated auto-open/close behavior.
+ * Automatically opens when new errors appear and closes when all errors are cleared.
+ * Tracks manual dismissal to prevent reopening for the same error.
+ *
+ * This hook is designed for multi-source error handling (e.g., machine errors,
+ * pyodide errors, error messages) and provides a consistent UX for error notification.
+ *
+ * @param options - Configuration options
+ * @param options.machineError - Current machine error code
+ * @param options.machineErrorMessage - Current machine error message
+ * @param options.pyodideError - Current Pyodide error message
+ * @param options.hasError - Function to check if an error code represents an error
+ * @returns Object containing popover state and control functions
+ *
+ * @example
+ * ```tsx
+ * const { isOpen, handleOpenChange } = useErrorPopoverState({
+ * machineError,
+ * machineErrorMessage,
+ * pyodideError,
+ * hasError: (code) => code !== 0 && code !== undefined
+ * });
+ *
+ * return (
+ * <Popover open={isOpen} onOpenChange={handleOpenChange}>
+ * <PopoverContent>{errorMessage}</PopoverContent>
+ * </Popover>
+ * );
+ * ```
+ */
+
+import { useState, useEffect } from "react";
+import { usePrevious } from "../utility/usePrevious";
+
+export interface UseErrorPopoverStateOptions {
+ machineError: number | undefined;
+ machineErrorMessage: string | null;
+ pyodideError: string | null;
+ hasError: (error: number | undefined) => boolean;
+}
+
+export interface UseErrorPopoverStateReturn {
+ isOpen: boolean;
+ handleOpenChange: (open: boolean) => void;
+ dismissedErrorCode: number | null;
+ wasManuallyDismissed: boolean;
+}
+
+export function useErrorPopoverState(
+ options: UseErrorPopoverStateOptions,
+): UseErrorPopoverStateReturn {
+ const { machineError, machineErrorMessage, pyodideError, hasError } = options;
+
+ // Internal state
+ const [isOpen, setIsOpen] = useState(false);
+ const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
+ null,
+ );
+ const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false);
+
+ // Track previous values for comparison
+ const prevMachineError = usePrevious(machineError);
+ const prevErrorMessage = usePrevious(machineErrorMessage);
+ const prevPyodideError = usePrevious(pyodideError);
+
+ // Auto-open/close logic
+ /* eslint-disable react-hooks/set-state-in-effect */
+ useEffect(() => {
+ // Check if there's any error now
+ const hasAnyError =
+ machineErrorMessage || pyodideError || hasError(machineError);
+ // Check if there was any error before
+ const hadAnyError =
+ prevErrorMessage || prevPyodideError || hasError(prevMachineError);
+
+ // Auto-open popover when new error appears (but not if user manually dismissed)
+ const isNewMachineError =
+ hasError(machineError) &&
+ machineError !== prevMachineError &&
+ machineError !== dismissedErrorCode;
+ const isNewErrorMessage =
+ machineErrorMessage && machineErrorMessage !== prevErrorMessage;
+ const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError;
+
+ if (
+ !wasManuallyDismissed &&
+ (isNewMachineError || isNewErrorMessage || isNewPyodideError)
+ ) {
+ setIsOpen(true);
+ }
+
+ // Auto-close popover when all errors are cleared
+ if (!hasAnyError && hadAnyError) {
+ setIsOpen(false);
+ setDismissedErrorCode(null); // Reset dismissed tracking
+ setWasManuallyDismissed(false); // Reset manual dismissal flag
+ }
+ }, [
+ machineError,
+ machineErrorMessage,
+ pyodideError,
+ dismissedErrorCode,
+ wasManuallyDismissed,
+ prevMachineError,
+ prevErrorMessage,
+ prevPyodideError,
+ hasError,
+ ]);
+ /* eslint-enable react-hooks/set-state-in-effect */
+
+ // Handle manual popover dismiss
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open);
+
+ // If user manually closes it while any error is present, remember this to prevent reopening
+ if (
+ !open &&
+ (hasError(machineError) || machineErrorMessage || pyodideError)
+ ) {
+ setWasManuallyDismissed(true);
+ // Also track the specific machine error code if present
+ if (hasError(machineError)) {
+ setDismissedErrorCode(machineError);
+ }
+ }
+ };
+
+ return {
+ isOpen,
+ handleOpenChange,
+ dismissedErrorCode,
+ wasManuallyDismissed,
+ };
+}

diff --git a/src/hooks/domain/useMachinePolling.ts b/src/hooks/domain/useMachinePolling.ts
new file mode 100644
index 0000000..477b9ba
--- /dev/null
+++ b/src/hooks/domain/useMachinePolling.ts
@@ -0,0 +1,174 @@
+/**
+ * useMachinePolling Hook
+ *
+ * Implements dynamic polling for machine status based on machine state.
+ * Uses adaptive polling intervals and conditional progress polling during sewing.
+ *
+ * Polling intervals:
+ * - 500ms for active states (SEWING, MASK_TRACING, SEWING_DATA_RECEIVE)
+ * - 1000ms for waiting states (COLOR_CHANGE_WAIT, MASK_TRACE_LOCK_WAIT, SEWING_WAIT)
+ * - 2000ms for idle/other states
+ *
+ * Additionally polls service count every 10 seconds.
+ *
+ * @param options - Configuration options
+ * @param options.machineStatus - Current machine status to determine polling interval
+ * @param options.patternInfo - Current pattern info for resumable pattern check
+ * @param options.onStatusRefresh - Callback to refresh machine status
+ * @param options.onProgressRefresh - Callback to refresh sewing progress
+ * @param options.onServiceCountRefresh - Callback to refresh service count
+ * @param options.onPatternInfoRefresh - Callback to refresh pattern info
+ * @param options.shouldCheckResumablePattern - Function to check if resumable pattern exists
+ * @returns Object containing start/stop functions and polling state
+ *
+ * @example
+ * ```tsx
+ * const { startPolling, stopPolling, isPolling } = useMachinePolling({
+ * machineStatus,
+ * patternInfo,
+ * onStatusRefresh: async () => { ... },
+ * onProgressRefresh: async () => { ... },
+ * onServiceCountRefresh: async () => { ... },
+ * onPatternInfoRefresh: async () => { ... },
+ * shouldCheckResumablePattern: () => resumeAvailable
+ * });
+ *
+ * useEffect(() => {
+ * startPolling();
+ * return () => stopPolling();
+ * }, []);
+ * ```
+ */
+
+import { useState, useCallback, useRef, useEffect } from "react";
+import { MachineStatus } from "../../types/machine";
+import type { PatternInfo } from "../../types/machine";
+
+export interface UseMachinePollingOptions {
+ machineStatus: MachineStatus;
+ patternInfo: PatternInfo | null;
+ onStatusRefresh: () => Promise<void>;
+ onProgressRefresh: () => Promise<void>;
+ onServiceCountRefresh: () => Promise<void>;
+ onPatternInfoRefresh: () => Promise<void>;
+ shouldCheckResumablePattern: () => boolean;
+}
+
+export interface UseMachinePollingReturn {
+ startPolling: () => void;
+ stopPolling: () => void;
+ isPolling: boolean;
+}
+
+export function useMachinePolling(
+ options: UseMachinePollingOptions,
+): UseMachinePollingReturn {
+ const {
+ machineStatus,
+ patternInfo,
+ onStatusRefresh,
+ onProgressRefresh,
+ onServiceCountRefresh,
+ onPatternInfoRefresh,
+ shouldCheckResumablePattern,
+ } = options;
+
+ const [isPolling, setIsPolling] = useState(false);
+ const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
+ const serviceCountIntervalRef = useRef<NodeJS.Timeout | null>(null);
+ const pollFunctionRef = useRef<() => Promise<void>>();
+
+ // Function to determine polling interval based on machine status
+ const getPollInterval = useCallback((status: MachineStatus) => {
+ // Fast polling for active states
+ if (
+ status === MachineStatus.SEWING ||
+ status === MachineStatus.MASK_TRACING ||
+ status === MachineStatus.SEWING_DATA_RECEIVE
+ ) {
+ return 500;
+ } else if (
+ status === MachineStatus.COLOR_CHANGE_WAIT ||
+ status === MachineStatus.MASK_TRACE_LOCK_WAIT ||
+ status === MachineStatus.SEWING_WAIT
+ ) {
+ return 1000;
+ }
+ return 2000; // Default for idle states
+ }, []);
+
+ // Main polling function
+ const poll = useCallback(async () => {
+ await onStatusRefresh();
+
+ // Refresh progress during sewing
+ if (machineStatus === MachineStatus.SEWING) {
+ await onProgressRefresh();
+ }
+
+ // Check if we have a cached pattern and pattern info needs refreshing
+ // This follows the app's logic for resumable patterns
+ if (shouldCheckResumablePattern() && patternInfo?.totalStitches === 0) {
+ await onPatternInfoRefresh();
+ }
+
+ // Schedule next poll with updated interval
+ const newInterval = getPollInterval(machineStatus);
+ if (pollFunctionRef.current) {
+ pollTimeoutRef.current = setTimeout(pollFunctionRef.current, newInterval);
+ }
+ }, [
+ machineStatus,
+ patternInfo,
+ onStatusRefresh,
+ onProgressRefresh,
+ onPatternInfoRefresh,
+ shouldCheckResumablePattern,
+ getPollInterval,
+ ]);
+
+ // Store poll function in ref for recursive setTimeout
+ useEffect(() => {
+ pollFunctionRef.current = poll;
+ }, [poll]);
+
+ const stopPolling = useCallback(() => {
+ if (pollTimeoutRef.current) {
+ clearTimeout(pollTimeoutRef.current);
+ pollTimeoutRef.current = null;
+ }
+
+ if (serviceCountIntervalRef.current) {
+ clearInterval(serviceCountIntervalRef.current);
+ serviceCountIntervalRef.current = null;
+ }
+
+ setIsPolling(false);
+ }, []);
+
+ const startPolling = useCallback(() => {
+ // Stop any existing polling
+ stopPolling();
+
+ // Start main polling
+ const initialInterval = getPollInterval(machineStatus);
+ pollTimeoutRef.current = setTimeout(poll, initialInterval);
+
+ // Start service count polling (every 10 seconds)
+ serviceCountIntervalRef.current = setInterval(onServiceCountRefresh, 10000);
+
+ setIsPolling(true);
+ }, [
+ machineStatus,
+ poll,
+ stopPolling,
+ getPollInterval,
+ onServiceCountRefresh,
+ ]);
+
+ return {
+ startPolling,
+ stopPolling,
+ isPolling,
+ };
+}

diff --git a/src/hooks/usePatternRotationUpload.ts b/src/hooks/domain/usePatternRotationUpload.ts
similarity index 100%
rename from src/hooks/usePatternRotationUpload.ts
rename to src/hooks/domain/usePatternRotationUpload.ts

diff --git a/src/hooks/usePatternValidation.ts b/src/hooks/domain/usePatternValidation.ts
similarity index 100%
rename from src/hooks/usePatternValidation.ts
rename to src/hooks/domain/usePatternValidation.ts

diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..7226d00
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1,11 @@
+// Utility Hooks - Generic, reusable patterns
+export * from "./utility";
+
+// Domain Hooks - Business logic for embroidery/pattern operations
+export * from "./domain";
+
+// UI Hooks - Library/framework integration (Konva, etc.)
+export * from "./ui";
+
+// Platform Hooks - Electron/Pyodide specific functionality
+export * from "./platform";

diff --git a/src/hooks/platform/index.ts b/src/hooks/platform/index.ts
new file mode 100644
index 0000000..426914c
--- /dev/null
+++ b/src/hooks/platform/index.ts
@@ -0,0 +1,3 @@
+export { useFileUpload } from "./useFileUpload";
+export { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
+export type { UseBluetoothDeviceListenerReturn } from "./useBluetoothDeviceListener";

diff --git a/src/hooks/platform/useBluetoothDeviceListener.ts b/src/hooks/platform/useBluetoothDeviceListener.ts
new file mode 100644
index 0000000..850cadf
--- /dev/null
+++ b/src/hooks/platform/useBluetoothDeviceListener.ts
@@ -0,0 +1,90 @@
+/**
+ * useBluetoothDeviceListener Hook
+ *
+ * Listens for Bluetooth device discovery events from Electron IPC.
+ * Automatically manages device list state and provides platform detection.
+ *
+ * This hook is Electron-specific and will gracefully handle browser environments
+ * by returning empty state.
+ *
+ * @param onDevicesChanged - Optional callback when device list changes
+ * @returns Object containing devices array, scanning state, and platform support flag
+ *
+ * @example
+ * ```tsx
+ * const { devices, isScanning, isSupported } = useBluetoothDeviceListener(
+ * (devices) => {
+ * if (devices.length > 0) {
+ * console.log('Devices found:', devices);
+ * }
+ * }
+ * );
+ *
+ * if (!isSupported) {
+ * return <div>Bluetooth pairing only available in Electron app</div>;
+ * }
+ *
+ * return (
+ * <div>
+ * {isScanning && <p>Scanning...</p>}
+ * {devices.map(device => <div key={device.id}>{device.name}</div>)}
+ * </div>
+ * );
+ * ```
+ */
+
+import { useEffect, useState } from "react";
+import type { BluetoothDevice } from "../../types/electron";
+
+export interface UseBluetoothDeviceListenerReturn {
+ devices: BluetoothDevice[];
+ isScanning: boolean;
+ isSupported: boolean;
+}
+
+export function useBluetoothDeviceListener(
+ onDevicesChanged?: (devices: BluetoothDevice[]) => void,
+): UseBluetoothDeviceListenerReturn {
+ const [devices, setDevices] = useState<BluetoothDevice[]>([]);
+ const [isScanning, setIsScanning] = useState(false);
+
+ // Check if Electron API is available
+ const isSupported =
+ typeof window !== "undefined" &&
+ !!window.electronAPI?.onBluetoothDeviceList;
+
+ useEffect(() => {
+ // Only set up listener in Electron
+ if (!isSupported) {
+ return;
+ }
+
+ const handleDeviceList = (deviceList: BluetoothDevice[]) => {
+ setDevices(deviceList);
+
+ // Start scanning when first update received
+ if (deviceList.length === 0) {
+ setIsScanning(true);
+ } else {
+ // Stop showing scanning state once we have devices
+ setIsScanning(false);
+ }
+
+ // Call optional callback
+ onDevicesChanged?.(deviceList);
+ };
+
+ // Register listener
+ window.electronAPI!.onBluetoothDeviceList(handleDeviceList);
+
+ // Note: Electron IPC listeners are typically not cleaned up individually
+ // as they're meant to persist. If cleanup is needed, the Electron main
+ // process should handle it.
+ }, [isSupported, onDevicesChanged]);
+
+ return {
+ devices,
+ isScanning,
+ isSupported,
+ };
+}

diff --git a/src/hooks/useFileUpload.ts b/src/hooks/platform/useFileUpload.ts
similarity index 100%
rename from src/hooks/useFileUpload.ts
rename to src/hooks/platform/useFileUpload.ts

diff --git a/src/hooks/ui/index.ts b/src/hooks/ui/index.ts
new file mode 100644
index 0000000..91bc173
--- /dev/null
+++ b/src/hooks/ui/index.ts
@@ -0,0 +1,2 @@
+export { useCanvasViewport } from "./useCanvasViewport";
+export { usePatternTransform } from "./usePatternTransform";

diff --git a/src/hooks/useCanvasViewport.ts b/src/hooks/ui/useCanvasViewport.ts
similarity index 100%
rename from src/hooks/useCanvasViewport.ts
rename to src/hooks/ui/useCanvasViewport.ts

diff --git a/src/hooks/usePatternTransform.ts b/src/hooks/ui/usePatternTransform.ts
similarity index 100%
rename from src/hooks/usePatternTransform.ts
rename to src/hooks/ui/usePatternTransform.ts

diff --git a/src/hooks/utility/index.ts b/src/hooks/utility/index.ts
new file mode 100644
index 0000000..a8e2ede
--- /dev/null
+++ b/src/hooks/utility/index.ts
@@ -0,0 +1,5 @@
+export { usePrevious } from "./usePrevious";
+export { useAutoScroll } from "./useAutoScroll";
+export { useClickOutside } from "./useClickOutside";
+export type { UseAutoScrollOptions } from "./useAutoScroll";
+export type { UseClickOutsideOptions } from "./useClickOutside";

diff --git a/src/hooks/utility/useAutoScroll.ts b/src/hooks/utility/useAutoScroll.ts
new file mode 100644
index 0000000..41a9946
--- /dev/null
+++ b/src/hooks/utility/useAutoScroll.ts
@@ -0,0 +1,51 @@
+/**
+ * useAutoScroll Hook
+ *
+ * Automatically scrolls an element into view when a dependency changes.
+ * Useful for keeping the current item visible in scrollable lists.
+ *
+ * @param dependency - The value to watch for changes (e.g., current index)
+ * @param options - Scroll behavior options
+ * @returns RefObject to attach to the element that should be scrolled into view
+ *
+ * @example
+ * ```tsx
+ * const currentItemRef = useAutoScroll(currentIndex, {
+ * behavior: "smooth",
+ * block: "nearest"
+ * });
+ *
+ * return (
+ * <div ref={isCurrent ? currentItemRef : null}>
+ * Current Item
+ * </div>
+ * );
+ * ```
+ */
+
+import { useEffect, useRef, type RefObject } from "react";
+
+export interface UseAutoScrollOptions {
+ behavior?: ScrollBehavior;
+ block?: ScrollLogicalPosition;
+ inline?: ScrollLogicalPosition;
+}
+
+export function useAutoScroll<T extends HTMLElement>(
+ dependency: unknown,
+ options?: UseAutoScrollOptions,
+): RefObject<T> {
+ const ref = useRef<T>(null);
+
+ useEffect(() => {
+ if (ref.current) {
+ ref.current.scrollIntoView({
+ behavior: options?.behavior || "smooth",
+ block: options?.block || "nearest",
+ inline: options?.inline,
+ });
+ }
+ }, [dependency, options?.behavior, options?.block, options?.inline]);
+
+ return ref;
+}

diff --git a/src/hooks/utility/useClickOutside.ts b/src/hooks/utility/useClickOutside.ts
new file mode 100644
index 0000000..f1a234b
--- /dev/null
+++ b/src/hooks/utility/useClickOutside.ts
@@ -0,0 +1,89 @@
+/**
+ * useClickOutside Hook
+ *
+ * Detects clicks outside a referenced element and executes a handler function.
+ * Useful for closing dropdown menus, popovers, modals, and other overlay UI elements.
+ *
+ * @param ref - Reference to the element to monitor for outside clicks
+ * @param handler - Callback function to execute when outside click is detected
+ * @param options - Configuration options
+ * @param options.enabled - Whether the listener is active (default: true)
+ * @param options.excludeRefs - Array of refs that should not trigger the handler when clicked
+ *
+ * @example
+ * ```tsx
+ * const dropdownRef = useRef<HTMLDivElement>(null);
+ * const buttonRef = useRef<HTMLButtonElement>(null);
+ *
+ * useClickOutside(
+ * dropdownRef,
+ * () => setIsOpen(false),
+ * {
+ * enabled: isOpen,
+ * excludeRefs: [buttonRef] // Don't close when clicking the button
+ * }
+ * );
+ *
+ * return (
+ * <>
+ * <button ref={buttonRef}>Toggle</button>
+ * {isOpen && <div ref={dropdownRef}>Content</div>}
+ * </>
+ * );
+ * ```
+ */
+
+import { useEffect, type RefObject } from "react";
+
+export interface UseClickOutsideOptions {
+ enabled?: boolean;
+ excludeRefs?: (
+ | RefObject<HTMLElement>
+ | { current: Record<string, HTMLElement | null> }
+ )[];
+}
+
+export function useClickOutside<T extends HTMLElement>(
+ ref: RefObject<T>,
+ handler: (event: MouseEvent) => void,
+ options?: UseClickOutsideOptions,
+): void {
+ const { enabled = true, excludeRefs = [] } = options || {};
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ // Check if click is outside the main ref
+ if (ref.current && !ref.current.contains(event.target as Node)) {
+ // Check if click is on any excluded refs
+ const clickedExcluded = excludeRefs.some((excludeRef) => {
+ if (!excludeRef.current) return false;
+
+ // Handle object of refs (e.g., { [key: number]: HTMLElement | null })
+ if (
+ typeof excludeRef.current === "object" &&
+ !("nodeType" in excludeRef.current)
+ ) {
+ return Object.values(excludeRef.current).some((element) =>
+ element?.contains(event.target as Node),
+ );
+ }
+
+ // Handle single ref
+ return (excludeRef.current as HTMLElement).contains(
+ event.target as Node,
+ );
+ });
+
+ // Only call handler if click was not on excluded elements
+ if (!clickedExcluded) {
+ handler(event);
+ }
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, [ref, handler, enabled, excludeRefs]);
+}

diff --git a/src/hooks/usePrevious.ts b/src/hooks/utility/usePrevious.ts
similarity index 100%
rename from src/hooks/usePrevious.ts
rename to src/hooks/utility/usePrevious.ts

Served by rngit 1.3.3 - Generated in 0.29s